選擇 Flutter 的一個優點就是其提供了優異的工具可協助開發 - 從編輯器整合到測試等。對於應用程式開發尤其重要,不同於網頁開發,在應用程式開發中錯誤的修復可能需要個幾天時間才能通過審核發佈。
這篇文章,我們將學習如何測試以便在早期發現錯誤,後續我們將進一步探索如何使用除錯工具來找出問題,分析效能瓶頸,還有檢查 UI 組件。
我們將從單元測試開始。如果我們在建置會重複使用的函式庫,並在多個應用程式中使用,那麼我們需要確保變更都能依照預期一樣運作。此時單元測試會非常有幫助。
大部分的人都認同,要程式完全沒有 Bug 幾乎是不可能的,尤其是當程式在第三方硬體上運行,例如手機,並且使用者可能做出各種意想不到的操作。
但對於某些情境,如函式庫開發,需求是可以被明確定義的,資料的輸入和輸出也可以預先設計。在這種情況下,完善的測試不僅可以減少 Bug 的發生還可以在增加功能或變更程式碼的時候確保變更不會影響預期的行為。
單元測試是確保程式碼品質的重要工具,除了提前發現錯誤,還可以促進程式碼的設計和模組化。
如其名稱,單元測試是對可以執行的最小邏輯程式碼單元進行測試,最通俗來說;就是一個函式。當然,單元測試並不是唯一的方法,但它以將程式碼行其他部分隔離,進行小片段程式碼的驗證檢查,幫助我們檢查聚焦在特定任務的程式單元。
100% 的覆蓋率並不能保證程式一定沒有錯誤,但它能幫助我們逐步實現穩定成熟的程式碼,這是確保良好開發週期的步驟之一,讓我們能盡可能的發佈穩定的程式碼。
當然 Dart 也支援一些實用的工具協助我們測試。讓我們進一步學習如何對 Dart 程式碼進行單元測試。
Dart 的測試套件並不是 SDK 的一部分,因此須安裝。但不像我們之前使用的套件,這個套件只需要在開發時期使用,正式發佈的版本並不需要。因此相依屬於 dev_dependencies
。
這裡我們要使用的是 flutter_test
這是一個專為 Flutter 測試而設計的套件,底層基於 Dart test
套件並增加 Flutter 特定的功能。在 pubspec.yaml
設定並使用 flutter pub get
指令
dev_dependencies:
flutter_test:
sdk: flutter
這個設定和我們之前常見的相依套件設定不太一樣,這個套件還包含了 Flutter SDK。
又或者:
$ flutter pub add dev:flutter_test --sdk=flutter
$ flutter pub add 'dev:flutter_test:{"sdk":"flutter"}'
假設我們有一個相加 2 數的函式
class Calculator {
num sum(num a, num b) {
return 0;
}
}
通常這類輔助函式我們可能選擇將其放置在例如 lib/maths/calculator.dart
路徑。
├── lib/
│ └── maths/
│ └── calculator.dart
└── test/
└── maths/
└── calculator_test.dart
接著我們可以開始撰寫測試:
import 'package:flutter_test/flutter_test.dart';
import 'package:test_demo/maths/calculator.dart';
void main() {
late Calculator calculator;
setUp(() {
calculator = Calculator();
});
test(
'calculator.sum() 應回傳兩數相加的結果',
() => expect(calculator.sum(1, 2), 3),
);
}
上面範例我們首先匯入測試套件,然後就可以使用其功能如 setUp
test
expect
這些函式。這些函式都有其負責的任務。
setUp
在每個測試執行之前執行傳入的 callback 如果有需要在全部測試開始前一次性的設定或操作則使用 setUpAll
test
測試案例本身,傳入一個描述和一個測試的 callbackexpect
測試斷言,也就是 1+2 應該要等於 3全部的測試程式碼都應置放在 test
目錄下,要執行的話則使用
$ flutter test [測試路徑]
$ flutter test test/maths/calculator_test.dart
00:05 +0: calculator.sum() 應回傳兩數相加的結果 00:05 +0 -1: calculator.sum() 應回傳兩數相加的結果 [E]
Expected: <3>
Actual: <0>
package:matcher expect
package:flutter_test/src/widget_tester.dart 474:18 expect
test/maths/calculator_test.dart 13:11 main.<fn>
To run this test again: /Users/andyyou/Workspaces/flutter/bin/cache/dart-sdk/bin/dart test /Users/andyyou/Workspaces/mobile/test_demo/test/maths/calculator_test.dart -p vm --plain-name 'calculator.sum() 應回傳兩數相加的結果'
00:05 +0 -1: Some tests failed.
讓我們來看看這些訊息代表什麼意思:
首先 00:05 +0: calculator.sum()
表示這個測試執行了 5 秒 0 個案例通過,1 個測試案例失敗。
[E]
表示前面描述的測試失敗了,下面會記錄預期的值,和執行的值,也就是預期是 3 但這裡計算為 0。錯誤發生在檔案的 13 行。
有了這些資訊我們就可以知道是哪個測試案例失敗了,並進一步找到問題點。值得注意的是有時候不一定是專案程式碼有問題,也可能是測試案例寫錯了。
除了上面基本的使用方式,我們還可以將測試案例分組
import 'package:flutter_test/flutter_test.dart';
import 'package:test_demo/maths/calculator.dart';
void main() {
late Calculator calculator;
setUpAll(() {
print("一次性任務");
});
setUp(() {
print("每次測試前執行");
calculator = Calculator();
});
group("計算", () {
test(
'正數相加',
() => expect(calculator.sum(1, 2), 3),
);
test(
'相加負數',
() => expect(calculator.sum(1, -1), 0),
);
});
}
單元測試可以協助我們避免邏輯上的錯誤。但測試案例不太可能窮舉所有的情況,這種時候搭配邊界值分析 (Boundary Value Analysis, BVA) 和等價類劃分 (Equivalence Partitioning, EP)等方式可以讓我們將大部分可能的情況都測到。
邊界值分析簡單說就是測試值使用邊界值例如欄位接受 0 - 100 那麼我們就可以使用 99,100, 101 這些值測試。等價類劃分則是將可能的值分組,例如訂單狀態可能有未付款、部分退款、已付款、全額退款等狀態,盡可能將這些分類都涵蓋。
有時候,單元測試案例可能需要存取服務或者從線上資料庫讀取資料。而測試環境可能不支援例如我們不希望測試操作線上正式環境的資料、資料庫或服務回傳了非預期的資料或不可重複性的問題等等都會造成我們的測試無法完成。
為了解決這個問題,單元測試通常會提供「模擬」的機制來移除這些服務或者資料庫的相依。你可以選擇自己撰寫這些模擬物件,通過自訂的模擬類別替換,又或者可以使用類似 Mockito 這類框架。
Mockito 是一個插件,我們可以安裝到專案的 dev 相依的部分,通過 Annotation 標註來產生需要的模擬物件。這裡就不展開介紹。
如同前面提到的,單元測試適合測試可以定義輸入輸出確認需求的函式庫。當開始涉及外部因素如使用者操作、網路、裝置時,我們需要考慮其他的測試方式例如組件測試
使用正確的測試組合對於測試的品質來說很重要,否則反而會降低我們開發迭代的速度和品質,上面提及當涉及使用者操作的時候,雖然我們總希望在確定設計之前了解使用者可能的操作,但設計卻會隨著使用者體驗、反饋、最佳實踐、主流設計風格的變化而調整改變。因此測試本身應該提高一個抽象階層,我們應該關注組件,而不是函式。Flutter 支援組件測試可以讓我們關注預期的組件行為。
組件測試採用隔離的方式來檢查組件。看起來像單元測試,但重點是檢查組件的操作行為,以及組件在顯示上是否符合預期。由於組件通常存在於 Flutter Context 的樹狀結構之中,測試需要使用框架的環境來執行。這就是 flutter_test
存在的原因,它會提供相關測試所需的功能支援。
前面我們提到 flutter_test
包含 Flutter SDK ,主要基於 Dart test
套件的基礎上加上一些協助我們實現組件測試的工具。組件測試須在對應的環境中執行,Flutter 主要透過 WidgetTester
類別來協助完成這個任務。這個類別封裝了建置,操作組件所需的環境。
我們不需要自己實例化這個類別,框架提供了 testWidgets()
函式。該函式和單元測試的 test()
類似,差別在於 Flutter Context - 也就是這個函式設定了 WidgetTester
物件來和執行環境溝通。
testWidgets('組件', (WidgetTester tester) async {
// ...
});
void testWidgets(
String description,
WidgetTesterCallback callback,
// 具名可選參數
{
bool skip: false,
Timeout timeout,
TestVariant<Object?> variant = const DefaultTestVariant(),
}
)
testWidgets
方法有兩個必須參數和三個可選參數
description
測試描述
callback
這個回調函式可以取得 WidgetTester
的物件實例,我們就可以操作組件進行測試。這個函式就是測試的主體,我們的測試邏輯會寫在這裡。
skip
設定跳過此測試。
timeout
設定測試的逾時時間,預設不限制。
variant
這個參數支援以不同的輸入值進行多次測試同一個案例。例如不同尺寸螢幕的測試:
testWidgets(
'測試不同螢幕尺寸',
(WidgetTester tester) async {
// 測試邏輯
},
variant: ScreenSizeVariant.all(), // 假設這是一個自訂的尺寸資料
);
當我們建立 Flutter 專案後,通常會自帶 flutter_test
套件以及一些簡單的測試範例。注意到 flutter_test
在 pubspec.yaml
設定並不包含版號,因為來源被設定為 Flutter SDK,所以它會匹配我們系統上安裝的 Flutter 版本。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:[PROJECT_NAME]/main.dart';
void main() {
testWidgets(
'計數器增加功能的快速 smoke test',
(WidgetTester tester) async {
// 構建 app 並渲染一幀
await tester.pumpWidget(MyApp());
// 檢查計數器功能由 0 開始
// find.text('0') 搜尋一個顯示文字為 '0' 的組件
// findsOneWidget 驗證是否只找到一個符合條件的組件
expect(find.text('0'), findsOneWidget);
// findsNothing 驗證沒有找到符合條件的組件
expect(find.text('1'), findsNothing);
// 點擊 + icon 並觸發更新一幀
await tester.tap(find.byIcon(Icons.add));
// 在測試中如果我們需要重新渲染,重新建構組件則須呼叫 pump()
// 也就是在測試環境中,雖然我們 setState 了,但 Flutter 測試環境不會自動 rebuild
await tester.pump();
// 檢查結果:'0' 不再顯示,而 '1' 顯示一次
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
}
);
}
上面範例是一個非常簡單的組件測試,我們用來檢查預設專案中的計數器功能是否正確。
更多關於 find 相關的用法可以參考官方教學 和 API 文件。 而 findsNothing
這類則屬於 Matcher
類別
組件測試使用的指令和單元測試一樣
$ flutter test [測試檔案路徑]
到此我們概覽了測試的基礎。下一篇我們將繼續學習如何偵錯。